Skip to content

docs(run-once): fix the examples that violate the key rule - #149

Merged
subhashkhileri merged 6 commits into
mainfrom
fix/run-once-project-scope
Aug 24, 2026
Merged

docs(run-once): fix the examples that violate the key rule#149
subhashkhileri merged 6 commits into
mainfrom
fix/run-once-project-scope

Conversation

@gustavolira

@gustavolira gustavolira commented Aug 20, 2026

Copy link
Copy Markdown
Member

Documentation only after review — the API change is reverted. git diff origin/main -- src/ package.json is empty.

The guide already had the right rule:

The key must be globally unique across all spec files and projects. Use a prefix that includes the workspace or project name.

Every example under it then used a literal key around configure() + deploy(), breaking the rule the paragraph had just given. That is what was worth fixing.

What changed

  • The key section says why a shared key breaks — the flag directory is keyed on the runner PID alone, nothing in the path comes from the project — and shows the fix as `${key}-${rhdh.deploymentConfig.namespace}`, which is what deploy() does internally and why deploy() was never affected.
  • Both intents, side by side: namespaced when the setup belongs to one project, literal when it is genuinely shared (an operator in a fixed namespace every project uses).
  • 12 examples fixed across the guide, API reference, deployment guide and the three overlay pages.
  • Nesting is no rescue: a shared outer key skips before deploy() is reached.

Not here: the warning that found affected call sites needed test.info(), which is the coupling being reverted. That moved to redhat-developer/rhdh-plugin-export-overlays#3375 as a static check.

yarn check clean, 111 tests, docs build clean.

gustavolira and others added 5 commits August 20, 2026 17:56
… silently

runOnce keys its flag file by the string alone, in a directory shared by every
project in the run:

    const flagDir = path.join(os.tmpdir(), `playwright-once-${process.ppid}`);
    const flagFile = path.join(flagDir, `${key}.done`);

Nothing in the key comes from the project. So when one spec is matched by more
than one project -- which is what adding an `-app-next` lane does -- the first
project's setup satisfies the second, and the second skips configure() and
deploy() entirely. It then fails much later on a missing element, with nothing
pointing at the cause. That happened on rhdh-plugin-export-overlays#3318 and
was caught in review; four workspaces still pass literal keys today.

Automatic per-project scoping would be wrong, because both intents are real:
installing an operator into a fixed namespace that every project then uses
genuinely wants once per run, while anything touching a project's own namespace
wants once per project. The API cannot guess, so this makes the caller say:

    await test.runOnce("my-setup", fn, { scope: "project" });

Default behaviour is unchanged.

For the keys that stay run-scoped, the dangerous case is no longer silent. The
flag file now records which project satisfied it, and a skip on behalf of a
*different* project logs a warning naming both and pointing at the option --
because that is nearly always the mistake rather than the intent.

Tests are new; there were none for this helper. Each was checked by mutation:
collapsing the scoped key back to `key` fails the per-project test, dropping the
warning guard fails the warning test, and warning unconditionally fails the
same-project test. Full suite 117/117, lint clean.

Part of RHIDP-16456.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Version Bump Check requires both for any change under src/, and it caught
that I had pushed neither. Documents the new scope option and the silent skip
it fixes.

Part of RHIDP-16456.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI runs `yarn check`, which is typecheck + lint:check + prettier:check. I had
run lint and the tests separately and missed the formatting gate, so the new
test file failed it. `yarn check` is the command to run locally.

Only the test file changed: prettier:check excludes docs/, so the changelog
needed no reformatting, and running --write over it had touched two unrelated
entries.

Part of RHIDP-16456.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e applied

The guides still said a runOnce key must be unique across "all spec files
and projects", and their two flagship examples wrapped configure() and
deploy() with no scope — which is exactly the shape that leaves a second
project with no deployment. Someone reading the guide rather than the
changelog would have written the bug.

Both examples now pass { scope: "project" }, the key section explains what
scope decides instead of asserting the old rule, and a new Scope section
gives the run-vs-project table plus the reason deploy() needs no scope of
its own: its internal key already carries the namespace.

Also close the one silent path the option still had. With scope "project"
and no resolvable project the key fell back to the bare form and behaved as
run-scoped again, without a word. It now warns, and the unit test for it
fails when the warning is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…project scope cannot be honoured

Review of the first two commits found the warning firing on the one case the
docs call correct, and the scope option still able to degrade quietly.

The warning fired on every cross-project skip of a run-scoped key, including
"install an operator into a fixed namespace every project then uses" — the
example the docs give for choosing "run". In an N-project run that printed N-1
warnings telling the author to switch to { scope: "project" }, which for that
setup would mean N operator installs, and there was no way to opt out because
the guard tested the resolved scope. It now tests `options.scope` as passed:
undefined means the author never thought about it, which is who the advice is
for; an explicit "run" is an answer and is left alone.

{ scope: "project" } with no resolvable project used to warn and then fall back
to the bare key — which is the shared-key bug it exists to prevent, reported by
one line easy to lose in CI output. It throws now. That only reaches callers
outside a Playwright context, since test.info() resolves inside beforeAll.

The flag file records which project satisfied a key, and the lock-free fast
path reads it from another process. writeFileSync truncates before it writes,
so a reader landing between the two steps saw an empty file and dropped the
warning — in the two-projects-one-spec case, the exact case it exists for.
Written through a temp file and renamed.

Docs: the earlier pass missed four files, including the whole overlay section,
which is the one rhdh-plugin-export-overlays follows and where the bug was
reported. Every documented runOnce block that wraps deploy() now passes the
scope — audited rather than spot-fixed — and the API reference documents the
third argument at all, which it did not. Also noted that nesting does not
rescue a missing scope: a run-scoped outer call skips before deploy() is
reached, so its internal protection never gets a say.

The changelog now leads with the fact that upgrading alone changes nothing:
the default is unchanged by design, so each affected call site has to opt in.

Both new behaviours were mutation-tested — reverting each turns exactly one
test red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gustavolira

Copy link
Copy Markdown
Member Author

Two review rounds landed on this branch since it opened. Summary for whoever picks it up.

What changed in the behaviour

{ scope: "project" } with no resolvable project now throws. It used to warn and fall back to the bare key — which is the shared-key bug the option exists to prevent, reported by one line that is easy to lose in CI output. This only reaches callers outside a Playwright context; test.info() resolves inside beforeAll, verified with a two-project probe.

The warning no longer fires on a deliberate { scope: "run" }. It was gating on the resolved scope, so the case the docs call correct — installing an operator into a fixed namespace every project then uses — printed N-1 warnings in an N-project run, advising a switch to { scope: "project" } that would mean N operator installs. There was no opt-out. It now gates on options.scope as passed: undefined means the author never considered it, which is who the advice is for.

The flag file is written atomically. Its content is what names the project in the warning, and the lock-free fast path reads it from another process. writeFileSync truncates before it writes, so a reader landing between the two steps saw an empty file and dropped the warning — in the two-projects-one-spec case, the exact case it exists for. Temp file plus renameSync.

Documentation

The first pass missed four files, including the entire docs/overlay/ section — the one rhdh-plugin-export-overlays follows, and where the bug was reported. Audited rather than spot-fixed: every documented runOnce block that wraps deploy() now passes the scope (18 blocks across 7 files), and docs/api/playwright/test-fixtures.md documents the third argument at all, which it did not.

Also recorded that nesting does not rescue a missing scope — a run-scoped outer call skips before deploy() is reached, so its internal protection never gets a say.

Verification

yarn check    typecheck + eslint (0 errors) + prettier clean
yarn test     119 passed, 0 failed
docs build    clean (validates internal links)

Both new behaviours are mutation-tested — reverting each turns exactly one test red:

Mutation Test that goes red
gate the warning on the resolved scope again leaves an explicitly chosen run scope alone
fall back instead of throwing throws when scope is project but no project can be determined

And end to end, one spec under two projects:

[ws]           project-scope executed=true   default executed=true   explicit-run executed=true
[ws-app-next]  project-scope executed=true   default executed=false  explicit-run executed=false
[runOnce] "k-default" was already run by project "ws", so project "ws-app-next" is skipping it. …

Note the third column: an explicit run skips silently, a defaulted one warns.

One thing worth stating plainly

Upgrading to 2.1.10 does not fix an affected call site. The default is unchanged by design, so every existing runOnce in the overlays repo still skips the second project until it opts in. What this release adds is the fix and a way to find the call sites that need it. The changelog now leads with that.

Also: 2.1.10 is free on npm today and no other open PR claims it, but the Version Bump Check only compares against main — if another version-bumping PR merges first, this one publishes nothing.

@subhashkhileri

subhashkhileri commented Aug 24, 2026

Copy link
Copy Markdown
Member

Hey, I’m not sure we need this in the helper.

In 2.1, the default one is the new front-end system, then why we are still running the old test cases in that way. If we have migrated successfully, then we can just remove the older one and use the new one, right?

Overlay CI still runs both shells on the same spec (plugin and plugin-app-next), which is the collision we already hit (rhdh-plugin-export-overlays#3318). If we drop the legacy project once app-next is the default, that skip goes away. Until then two projects in one Playwright run still share one runOnce flag dir.

Could we just put namespace in the key like deploy() already does (deploy-${namespace})? Something like my-plugin-setup-${rhdh.deploymentConfig.namespace} as the unique key is expected to come from the caller as per the desgin. Bulk-import already fixed the spec that way

runOnce isn’t tied to Playwright today, which is why deploy() and the unit tests can use it. { scope: "project" } plus test.info() couples it to a test, and defaulting to "run" means existing overlay keys stay broken until every caller opts in (a warning doesn’t actually run setup).

What if we keep runOnce generic, update the examples, and let call sites do what deploy() already does? its easy and no worries of regression.

Review is right and this reverts the code change entirely.

The rule this guide already stated was correct — "the key must be globally
unique across all spec files and projects... use a prefix that includes the
workspace or project name". What was wrong is that every example underneath it
then used a literal key around configure() and deploy(), violating the rule the
paragraph had just given. Someone reading the examples rather than the sentence
wrote the bug; the earlier version of this branch responded by changing the
sentence, which was the wrong half to move.

So runOnce goes back to what it was. The { scope: "project" } option, the
import of @playwright/test into a module that had no test-runner dependency,
and the version bump are all gone. deploy() and the unit tests can keep using
runOnce outside a Playwright context, which was the point of it being generic.

What lands is the documentation:

- the key section now says why a project-shared key breaks, quoting the flag
  directory that is keyed on the runner PID alone, and shows the fix as
  `${key}-${rhdh.deploymentConfig.namespace}` — which is what deploy() has
  always done internally, and why deploy() was never affected;
- both intents are spelled out side by side, because both are real: a namespaced
  key for setup that belongs to one project, a literal one for an operator
  installed into a fixed namespace every project then uses;
- every example that wraps deploy() carries the namespace — twelve of them,
  across the guide, the API reference, the deployment guide and all three
  overlay pages;
- nesting is noted as no rescue: a project-shared outer key skips before
  deploy() is reached, so its internal protection never gets a say.

Not carried over: the warning when a shared key is skipped for another project.
It needed test.info(), which is the coupling being reverted. That gap is better
closed in rhdh-plugin-export-overlays, where a static check can find a literal
key in a workspace whose config declares two projects over one spec — before a
deployment rather than after one.

RHIDP-16456.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gustavolira added a commit that referenced this pull request Aug 24, 2026
#149 was reduced to a documentation change, so it no longer bumps the version and
2.1.10 is free. The note about the two PRs colliding goes with it — there is only
one version-bumping PR open now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gustavolira gustavolira changed the title fix(run-once): let callers scope a key per project, and stop skipping silently docs(run-once): fix the examples that violate the key rule Aug 24, 2026
gustavolira added a commit that referenced this pull request Aug 24, 2026
#151 merged today and claimed 2.1.10, so the renumber in the previous commit
collided with main and the Version Bump Check would have refused it. Same number
as before, different reason: this is no longer about #149, which now bumps
nothing at all.

Worth noting separately: main says 2.1.10 but the publish workflow for it failed,
so npm latest is still 2.1.9. If that is not resolved before this merges, 2.1.11
publishes over a 2.1.10 that never existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gustavolira

Copy link
Copy Markdown
Member Author

You're right — reverted the whole code change. runOnce is identical to main again: no scope, no @playwright/test import, no version bump.

I had it backwards. The rule in the guide was already correct; what was broken is that every example under it used a literal key around configure() + deploy(). I changed the sentence when the examples were the wrong half. Fixed 12 of them, and the guide now says why it breaks and shows both cases side by side — a literal key is right for something like the orchestrator install into a fixed namespace.

Small thing, not to reopen it: "existing keys stay broken until callers opt in" is equally true of adding -${namespace} — that's also a per-call-site change. Just so it doesn't get filed under a reason that doesn't separate the two options.

What does get lost is the warning that found the bad call sites, since it needed test.info(). Moved that half to redhat-developer/rhdh-plugin-export-overlays#3375 — a static check that finds a literal key in a workspace where two projects run the same spec. Green on that repo today, red if you reconstruct the #3318 shape.

One back at you: dropping the legacy lanes once app-next is default is RHIDP-16460, still New and unassigned. Until someone owns it the two-project window stays open.

@subhashkhileri

Copy link
Copy Markdown
Member

/lgtm

@openshift-ci openshift-ci Bot added the lgtm label Aug 24, 2026
@subhashkhileri
subhashkhileri merged commit e5ac2b1 into main Aug 24, 2026
3 checks passed
subhashkhileri pushed a commit that referenced this pull request Aug 26, 2026
…oting legacy (#152)

* fix(nfs): make a lane that is not actually running NFS fail instead of pass

Three defects found while triaging the overlay migration, all with the same
shape: the harness cannot tell a working NFS lane from one that quietly fell
back to the legacy shell.

RHIDP-16457 — a workspace could disable the NFS it asked for. The NFS secret
layer merges before the workspace's own tests/config/rhdh-secrets.yaml, so a
workspace setting APP_CONFIG_app_packageName or ENABLE_STANDARD_MODULE_FEDERATION
for its own reasons overrode it. Nothing then failed: the legacy suite re-ran
and passed, and the only thing the lane existed to prove was never exercised.
deploy() now checks the merged secret still carries both markers and throws
naming the key, the value found, and the file responsible. The merge order is
untouched — a workspace has to be able to override defaults, which is exactly
why the outcome needs checking rather than assuming.

Also from RHIDP-16457, and from RHIDP-16461: configure() now logs which of the
three mechanisms decided the shell, because none of them is visible from a
single file and the answer currently requires reading a project name, an
environment variable and a configure() call together. A namespace ending in
-app-next that was explicitly configured with useNewFrontendSystem: false warns,
since it deploys legacy under a name that reads as NFS everywhere. Only that
direction is checked: enabling NFS without the suffix is legitimate and is what
github and homepage do.

RHIDP-16459 — the GitHub session file was a bare relative authState_<user>.json,
resolved against process.cwd(), which the worker fixture sets to the same
workspace directory for every project. A workspace's lanes shared one file per
user, unlocked: one lane could inject another's storage state, and a reader
landing mid-write died on truncated JSON as a flake that looked nothing like the
plugin under test. Every lane added is another writer. The path is now absolute
and keyed by project as well as user, the write goes through a temp file and a
rename, and an unusable session falls through to a full login rather than
throwing.

RHIDP-16458 — SidebarTabs hardcoded "Self-service", which is the legacy shell's
scaffolder title; under app-next the page is "Create", so a spec running there
could not type-check without a cast. Both are members now. Deliberately only
that: which label a lane should use is RHIDP-16462, three workarounds are in
flight for it, and picking one here would be a fourth.

All four new behaviours are mutation-tested — reverting each turns the matching
tests red and nothing else. 136 tests pass; typecheck, eslint and prettier are
clean; the docs build validates its own links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(nfs): the session fix would have caused the failure it prevents

Review found two of the three changes wrong in ways the tests did not reach.

Keying the GitHub session file per project removed cross-lane sharing, which is
what the ticket asked for — and would have made every lane of a workspace log in
to the same GitHub account concurrently. logintoGithub derives its 2FA code from
one shared TOTP secret, so lanes starting inside the same 30-second window submit
the identical code and GitHub rejects the second. This file already carries retry
handling for exactly that error, which is how it is known to happen; bulk-import
would have gone from one login to three simultaneous ones.

So the file is one per user again, and what was actually missing is added
instead: a lock spanning read-or-login-and-write, not just the write, because two
lanes that both decide there is no session go on to log in anyway. RHDH cookies
from another lane were never the hazard — each lane's RHDH lives on its own
namespace hostname.

The [nfs] line was logged from configure(), but the worker fixture calls
configure() with no arguments for every project before any spec runs. For github
and homepage — the two lanes the change was written for, which opt in through
configure({ useNewFrontendSystem: true }) — the first line said "off, from
nothing", and the first line is the one a reader greps. Reported from deploy()
now, once, after configuration is final.

Two smaller ones. The guard accepted an unquoted YAML `true` by coercing in the
comparison only, so it green-lit a payload the API server rejects with "cannot
unmarshal bool into Go struct field ... of type string" — an opaque k8s failure
in place of the guard's own message. It now rewrites the value as well. And the
temp file survived a failed storageState write, accumulating untracked
authState*.tmp files in a directory nothing gitignores them from.

New tests for the lock, both write outcomes, and the boolean rewrite; the
serialisation test waits for the first holder rather than racing two callers
from the same tick, which was flaky. Mutation-tested: removing the lock, the
temp-file cleanup, or the in-place coercion each turns exactly one test red.
141 pass, five consecutive runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: take 2.1.10, now that #149 claims no version

#149 was reduced to a documentation change, so it no longer bumps the version and
2.1.10 is free. The note about the two PRs colliding goes with it — there is only
one version-bumping PR open now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: back to 2.1.11 — main took 2.1.10

#151 merged today and claimed 2.1.10, so the renumber in the previous commit
collided with main and the Version Bump Check would have refused it. Same number
as before, different reason: this is no longer about #149, which now bumps
nothing at all.

Worth noting separately: main says 2.1.10 but the publish workflow for it failed,
so npm latest is still 2.1.9. If that is not resolved before this merges, 2.1.11
publishes over a 2.1.10 that never existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(nfs): the deployment guide said the workspace secret always wins

It does not any more, and the guide said it in as many words: "your
rhdh-secrets.yaml still wins on conflicts". True for every key except the two
NFS markers, where overriding now throws instead of quietly deploying the legacy
shell — so the sentence this PR contradicts is corrected in the same change
rather than left to be discovered from a stack trace.

Also documented, because a new hard failure with no docs costs someone an hour:
the `[nfs]` line and what to grep for when a lane behaves like the wrong shell,
why it comes from deploy() rather than configure(), the exact throw and the fact
that it is a post-condition on the caller's own config, and the -app-next
namespace warning together with why the reverse is not checked.

The dynamic-plugins layer is named as the unguarded one. Disabling app-auth or
app-integrations from a workspace's own dynamic-plugins.yaml degrades the lane
the same silent way, and nothing catches it. No workspace does that today — 14
ship their own dynamic-plugins.yaml and none names either package — so a second
guard would be insurance against something nobody does, which is the argument
that just closed rhdh-plugin-export-overlays#3375. Documented instead, and it is
the first place to look when an NFS lane behaves like the legacy shell.

deploy()'s step list gains both the report and the throw.

141 tests pass, yarn check clean, docs build clean.

* fix(nfs): fail the lane on an intent conflict instead of warning about it

The guard found the conflict and then let the run continue. A lane named
<ws>-app-next configured with useNewFrontendSystem: false deployed the legacy
shell, re-ran the legacy suite, passed, and left one console.warn behind on a
run that exited 0 — which is indistinguishable from a run that had nothing to
say. That is the exact failure this module exists to stop, and the PR's own
title claims it does.

assertNfsMarkersSurvived already throws for the other path, where a workspace's
own rhdh-secrets.yaml overwrites the markers. The two paths now behave the same.

describeNfsIntentConflict stays a pure describer with its existing tests;
assertNfsIntentMatches is a thin wrapper over it so the throwing behaviour is
directly testable rather than reachable only through a private method on
RHDHDeployment. The [nfs] line is logged before the check so the resolved state
is on record even when the next line ends the run.

Mutation-verified: turning the throw back into a return fails
"fails the deploy on the conflict, rather than only naming it" and nothing else.

Closes the library half of RHIDP-16457 — bulk-import's local
expect(rhdh.deploymentConfig.useNewFrontendSystem).toBe(true) was stronger than
the library guard until now, which is the inverse of what that ticket asks for.

* fix(github-session): lock only session creation, and scope this PR to that

Two changes from review.

Lock scope. The lock spanned read-or-login-and-write, so the reuse path —
addCookies, goto, Sign In, wait for nav or popup — ran inside it too. Reuse
needs no exclusivity: the lanes are different namespace hosts and only creation
submits a TOTP. Every loginAsGithubUser therefore queued behind a sign-in it did
not need, and a waiter could exhaust Playwright's default test timeout before
proper-lockfile's own retries ran out, because test.setTimeout(260_000) is
raised inside logintoGithub — precisely the path a waiter is not on.

ensureGithubSession now checks for a session outside the lock and takes it only
to create one, re-reading inside so a caller that queued behind the lane that
created it reuses that session rather than logging in again with the same TOTP
code. loginAsGithubUser splits into _reuseGithubSession and
_createGithubSession; creation already leaves the page signed in, so the reuse
path is not replayed after it.

Mutation-verified, one test each: dropping the outer check fails "reuses an
existing session without taking the lock"; dropping the inner re-read fails
"creates once when two callers find no session at the same time".

The first version of that first test was wrong and passed under the mutation —
it started ensureGithubSession in the same tick as the lock holder, so it raced
the scheduler rather than the lock and the result depended on how loaded the run
was. It now waits until the lock is demonstrably held, and is bounded by a timer
so reintroducing the bug fails instead of deadlocking CI.

Scope. The NFS work is removed — nfs-guard, the deploy-time report and marker
check, the SidebarTabs label, and the docs for them. With the old frontend
coming out there is no legacy shell to silently fall back into, so guarding
against it earns little and would be deleted in the cleanup anyway.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants